Skip to content

[Adapters] Implement EmdashCartStore with an embedded mutation ledger and intent-claimed inventory edges - #255

Merged
vedanshujain merged 1 commit into
feat/in-process-commercefrom
feat/emdash-cart-store
Sep 13, 2026
Merged

vedanshujain merged 1 commit into
feat/in-process-commercefrom
feat/emdash-cart-store

Conversation

@vedanshujain

Copy link
Copy Markdown
Contributor

What

EmdashCartStore implements every method of the domain's CartStore port over one aggregate
document per cart
, with lines keyed by SKU and an embedded, bounded mutation ledger recorded in
the same conditional write as the line it describes. Every mutation that touches stock — adding or
adjusting a line, expiring a hold, checking out — is written as an explicit bracket: claim the
intent in the ledger, run the inventory movement through InventoryStore and nothing else, then
land a completion that any replayer can finish if the process dies mid-bracket. This is the tenth
increment of the effort to fold the commerce service into the EmDash plugin (work order 02).

Design points

  • Bounded ledger, unbounded safety. The embedded mutation ledger keeps the last 64 completed
    records and the last 16 abandoned (reaped-crash) records, evicting the oldest of each. A record
    that is claimed but neither completed nor abandoned is never pruned, at any age — it's the only
    thing that tells a replayer a mutation is still owed.
  • A locator collection for identifiers with no cart. Two of the port's methods — recording a
    mutation by key and expiring a hold by reservation id — are handed an identifier with no cart id
    attached. A small lookup collection maps those keys back to their owning cart, and it also scopes
    the expiry sweep: a reservation with no cart claim in the locator can never be reaped by the cart
    side.
  • The hold-deadline attach guard is a write, not a read. Stamping a hold's expiry deadline onto
    the inventory side happens through a small adapter-local extension on the inventory store, and
    it's applied as a guarded compare-and-swap step nested inside the cart's own CAS — never as a
    separate read followed by a write. That ordering means a hold that has already been reaped, or
    whose reservation has already settled, can never receive a new deadline; the guard fails instead,
    and the port's HoldExpiredError fires exactly where it should. This is what makes checkout able
    to adopt a cart's holds safely, and it's pinned by a new regression test that specifically
    reproduces the case where the stamp goes missing.
  • Hold expiry is a three-step handoff. expireHold first commits a once-only expiry token, then
    performs an idempotent release of the reserved stock, then removes the line — in that order — so
    that no matter where a crash lands, a replayer returns the stock exactly once and never twice.
  • Checkout is one guarded compare-and-swap. The cart's terminal state transition lands in a
    single conditional write, so a crash either lands the whole transition or none of it.
  • adjustLine repairs rather than retries. Once a mutation key is marked completed it never
    re-applies, but the line's stored quantity can still drift from the hold it references if the two
    writes race; a dedicated repair pass reconciles the line to the hold in place, with the completion
    preserved. A two-writer convergence race test proves both writers land on the same, correct end
    state.
  • A typed error for an invalid release. The inventory store's release operation, when called
    against a hold that is no longer live, now raises a specific, classifiable error type instead of
    succeeding silently or behaving ambiguously — closing the last untyped failure mode in the expiry
    path.

Crash seams proven

  • Claim recorded, but the inventory movement never ran — replay resumes and completes the movement
    exactly once.
  • The inventory movement landed, but the completion never wrote the line — replay attaches the same
    hold to the line without a second stock movement.
  • A hold-expiry crash right after the once-only expiry token is written, before stock is released —
    replay finishes the release and returns stock exactly once.
  • A hold-expiry crash after stock is released but before the line is removed — replay removes the
    now-stale line without returning stock a second time.
  • Checkout crashes right after the cart's terminal flip — replay is a safe no-op and never rewrites
    the already-recorded order id.
  • A hold is left live after its reservation has already gone terminal — the line is left alone
    rather than force-expired, because settling and pruning belong to the sweep, not the cart.
  • A reservation that has settled but hasn't been pruned yet is refused a fresh deadline stamp, so no
    line can ever attach itself to units that are already spent.
  • Releasing a hold that is no longer live now raises a typed, classifiable error instead of an
    ambiguous or silent outcome.

Verification

Check Result
Lint / typecheck / format / build Clean
SQLite 126 passed
Postgres 264 passed / 1 pre-existing skip — run twice, identical results
D1 96 passed
Cart contract suite 14 / 14 passed on SQLite, Postgres, and D1 — zero skips
Inventory contract suite Unchanged
Cart race test 15 loops × 50 racers contending on 5 units — exactly 5 winners every loop, max 7 of 12 allowed CAS attempts, zero contention errors

Review

Two independent reviews were conducted over three rounds. The first round caught that dropping the
hold-deadline stamp would have broken every checkout; this was fixed by making the stamp a guarded
write, with a new regression test case added to pin the behavior. The second round asked for two
one-line hardening fixes, both applied. Both reviewers approve. An independent verification run
passed twice on Postgres and once on D1.

🤖 Generated with Claude Code

https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X

… and intent-claimed inventory edges

The cart is the first commerce aggregate whose invariants cross into another one,
so the shape is explicit rather than implied. `carts/{cartId}` carries the lines
map keyed by sku (the old `(cart_id, sku)` unique index, made structural), the
embedded mutation ledger (the old `cart_mutations` table, read and written in the
SAME conditional write as the line it records) and a denormalized `holdExpiresAt`
(the old `expires_at <= now` scan target, since the filter algebra has no OR and
cannot reach inside a map). One lookup collection, `cart_mutation_index`, answers
the two port signatures that are handed an identifier with no cart id — and that
second hop is also the sweep's scoping, because a raw reserve has no cart claim.

Every mutation that touches stock is a bracket: claim in the ledger, the inventory
movement through `InventoryStore` and nothing else, then a completion that lands
the line and `completed: true` together. Hold expiry is the same shape with a
once-only token — onto the line, or onto the outstanding claim when the crash left
no line — so a partial expiry is completable by any replayer, only the minter
reports the reclaim, and stock returns exactly once. A fresh token is refused
while the reservation is already terminal, which is the obligation the inventory
tier hands every reaping path.

THE ATTACH GUARD IS A GUARDED WRITE, as `CartStore.upsertLine`'s contract requires:
the deadline stamp and the guard are one act, and a mere read cannot substitute
because the sweep can reap the hold between the read and the cart write. Since the
port declares no such method and widening it is a domain change this package may
not make, the capability is adapter-local — `HoldDeadlineStamper.stampHoldDeadline`,
implemented by `EmdashInventoryStore` as one compare-and-set in which the
`state = 'held'` precondition, the ownership check and the new deadline commit
together, returning false for an unknown, pruned or adopted hold and never touching
a non-held one. `EmdashCartStore` asks for `InventoryStore & HoldDeadlineStamper`
and calls it inside the compare-and-set step of `upsertLine` and `adjustLine`,
before the cart write (the SQL's fixed step order), so the guard is re-evaluated on
every attempt; a refusal is the port's `HoldExpiredError`. That also keeps
`adopt`/`adoptMany`'s `expiresAt > now` scope satisfied for cart holds, which a
cart-only deadline would have broken outright, and it is what makes the two
tolerated `release` refusals recognizable by type rather than by message.

`adjustLine`'s reconcile is a REPAIR, not a retry: once the key is completed the
mutation must never re-apply, but the stored qty still owes the hold agreement, so
a divergence is fixed in place with the completion preserved — a bare retry would
find `completed` and hand back the stale line.

Tests: the domain contract runs green on sqlite, Postgres and D1 with no skips; the
three dialect suites and the cart race gate are ported off the SQL adapter (the
crashed-hold state is now PRODUCED by the real claim and reserve rather than
hand-seeded); a new seams suite opens the interruption points, and the file states
which of its cases inject a fault and which do not rather than claiming all do; a
regression case drives `adoptMany` over a hold the cart attached, which is the only
thing that would notice the stamp going missing; a Postgres case races two
different-key adjusts and pins the convergence the repair buys. The inventory
contract harness now calls the real `stampHoldDeadline` instead of patching the
document by hand.

Also types the inventory store's `release` refusal on a non-live hold — the bare
`Error` becomes `ReservationNotReleasableError` with the same message — and fixes
the README's recorded drift on the merchant removal shape's measured contention
failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
@vedanshujain
vedanshujain merged commit 48afa91 into feat/in-process-commerce Sep 13, 2026
2 checks passed
@vedanshujain
vedanshujain deleted the feat/emdash-cart-store branch September 13, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant